Deepak Bastola
  • Research Projects
  • Courses
  • CV
  • Packages

On this page

  • Pulling the opinions
  • Cleaning and clustering
  • Reading sentiment in context
  • Wrapping up

Sentiment in legal opinions

Legal opinions are dense, formal, and full of intent — a natural place to look for sentiment if you know where to find it. This project walks through reading a set of court opinions as data: scraping them, cleaning the text, clustering the language, and finally picking apart the sentiment around specific aspects of a case. It’s aspect-based sentiment analysis, applied to the law.

Pulling the opinions

The first hurdle is just getting the text. I fetch the documents from a public case-law collection and parse the HTML with BeautifulSoup, pulling out the parties, docket number, decision date, judges, and the body of the opinion. Once the extraction routine is written and reliable, it’s easy to run it across a whole directory of cases and collect everything into one DataFrame.

import requests
from bs4 import BeautifulSoup
import pandas as pd
import warnings
warnings.filterwarnings('ignore', message='not allowed')
# URL of the legal document
url = 'https://static.case.law/md-app/1/html/0001-01.html'

# Fetch the HTML content
response = requests.get(url)
html_content = response.text

# Parse the HTML content
soup = BeautifulSoup(html_content, 'html.parser')
# Extract data using BeautifulSoup based on the classes
case_title = soup.find('h4', class_='parties').get_text(separator=" ", strip=True)
docket_number = soup.find('p', class_='docketnumber').get_text(strip=True)
decision_date = soup.find('p', class_='decisiondate').get_text(strip=True)
judges_involved = soup.find('p', class_='judges').get_text(strip=True)
court_opinion = soup.find('p', class_='author').find_next('p').get_text(strip=True)

# Extract all paragraphs of legal text
legal_texts = [p.get_text(" ", strip=True) for p in soup.find_all('p')[1:]]

# Compile extracted data into a DataFrame
data = {
    'Case Title': [case_title],
    'Docket Number': [docket_number],
    'Decision Date': [decision_date],
    'Judges Involved': [judges_involved],
    'Court Opinion': [court_opinion],
    'Legal Texts': ["\n\n".join(legal_texts)]
}

df = pd.DataFrame(data)
print(df['Legal Texts'][0])
# URL of the directory listing page
url = 'https://static.case.law/md-app/1/html/'

# Make the request
response = requests.get(url)
html_content = response.text

# Parse the HTML
soup = BeautifulSoup(html_content, 'html.parser')

file_links = [a['href'] for a in soup.find_all('a') if a['href'].endswith('.html')]

print(file_links)
def extract_information(html_content):
    # Parse the content with BeautifulSoup
    soup = BeautifulSoup(html_content, 'html.parser')

    # Extract various elements safely
    case_title = soup.find('h4', class_='parties')
    docket_number = soup.find('p', class_='docketnumber')
    decision_date = soup.find('p', class_='decisiondate')
    judges_involved = soup.find('p', class_='judges')
    court_opinion = soup.find('p', class_='author')

    # Use get_text if element exists, else use a default value
    case_title_text = case_title.get_text(separator=" ", strip=True) if case_title else "Title Not Found"
    docket_number_text = docket_number.get_text(strip=True) if docket_number else "Docket Number Not Found"
    decision_date_text = decision_date.get_text(strip=True) if decision_date else "Decision Date Not Found"
    judges_involved_text = judges_involved.get_text(strip=True) if judges_involved else "Judges Involved Not Found"
    court_opinion_text = court_opinion.find_next('p').get_text(strip=True) if court_opinion and court_opinion.find_next('p') else "Court Opinion Not Found"

    legal_texts = "\n\n".join([p.get_text(" ", strip=True) for p in soup.find_all('p')]) if soup.find_all('p') else "Legal Texts Not Found"

    return {
        'Case Title': case_title_text,
        'Docket Number': docket_number_text,
        'Decision Date': decision_date_text,
        'Judges Involved': judges_involved_text,
        'Court Opinion': court_opinion_text,
        'Legal Texts': legal_texts
    }
# DataFrame to store all cases information
cases_data = []  # Use a list to collect data

# Loop through each file link and extract data
for file_url in file_links:
    response = requests.get(file_url)
    if response.status_code == 200:
        case_info = extract_information(response.text)
        cases_data.append(case_info)  # Append dictionary to list
    else:
        print(f"Failed to download {file_url}")

# Convert the list of dictionaries to a DataFrame once after the loop
all_cases_df = pd.DataFrame(cases_data)

Cleaning and clustering

Raw HTML is messy, so the text gets cleaned first — tokenized, stopwords removed, normalized — before anything else. Then comes the feature engineering: for legal text, the presence of certain terms is more informative than raw word counts, so I weight the vocabulary with TF-IDF and ask KMeans to group the opinions into clusters. The clusters end up mapping to recognizable themes, like whether a case is an opinion on the merits or an application for leave.

import nltk
import re
nltk.download('punkt')
nltk.download('stopwords')
from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize
stop_words = set(stopwords.words('english'))
all_cases_df['Legal Texts Cleaned'] = all_cases_df['Legal Texts'].apply(lambda x: re.sub(r'[\[\]\n]', ' ', x))
all_cases_df['Legal Texts Tokenized'] = all_cases_df['Legal Texts Cleaned'].apply(word_tokenize)
#!pip install spacy
all_cases_df.to_csv('MD_Vol1.csv', index=False)
# df = pd.read_csv('MD_Vol1.csv')
from collections import Counter

all_words = [
    word for opinion in all_cases_df['Court Opinion']
    for word in word_tokenize(opinion.lower())
    if word not in stop_words and word.isalpha()
]

# Count frequencies and look at the most common terms
word_counts = Counter(all_words)
for word, freq in word_counts.most_common(20):
    print(f"{word}: {freq}")

Reading sentiment in context

What makes legal language tricky is that the same words can flip meaning depending on what they’re attached to. Here I use spaCy’s dependency parse to pull out the aspects in an opinion — the nouns that are being described — along with the adjectives or opinions linking to them. That way, sentiment is anchored to a specific subject rather than left floating over the whole document.

import spacy
# !python -m spacy download en_core_web_trf
nlp = spacy.load("en_core_web_trf")
# Example sentence
example_sentence = all_cases_df['Court Opinion'][104]
doc = nlp(example_sentence)

# Extract aspects and opinions
aspects = []
opinions = []
for token in doc:
    if token.dep_ == 'amod' and token.head.pos_ == 'NOUN':
        aspects.append(token.head.text)
        opinions.append(token.text)

print("Aspects:", aspects)
print("Opinions:", opinions)
# Function to clean text
def clean_text(text):
    tokens = word_tokenize(text.lower())  # Tokenize and lowercase
    filtered_tokens = [word for word in tokens if word.isalpha() and word not in stop_words]  # Remove punctuation and stopwords
    return ' '.join(filtered_tokens)

# Apply the cleaning function
all_cases_df['Cleaned Court Opinions'] = all_cases_df['Court Opinion'].apply(clean_text)
from sklearn.feature_extraction.text import TfidfVectorizer

vectorizer = TfidfVectorizer()
X = vectorizer.fit_transform(all_cases_df['Cleaned Court Opinions'])
from sklearn.cluster import KMeans

# Choosing a k-value (for example, 5 clusters) and fit K-means
k = 3
kmeans = KMeans(n_clusters=k, random_state=0)
kmeans.fit(X)

# Assign the cluster labels back to the DataFrame
all_cases_df['Cluster'] = kmeans.labels_

# Define cluster names based on your analysis or top words
cluster_names = {
    0: "Opinion Delivered Court",
    1: "Application Leave Appeal Denying",
    2: "Appellant Convicted Jury Sentenced"
}

# Map the cluster labels to names
all_cases_df['Cluster Name'] = all_cases_df['Cluster'].map(cluster_names)
import numpy as np
def get_top_features_cluster(tfidf_array, prediction, n_feats):
    labels = np.unique(prediction)
    dfs = []
    for label in labels:
        id_temp = np.where(prediction == label)
        x_means = np.mean(tfidf_array[id_temp], axis = 0)
        sorted_means = np.argsort(x_means)[::-1][:n_feats]
        features = vectorizer.get_feature_names_out()
        best_features = [(features[i], x_means[i]) for i in sorted_means]
        df = pd.DataFrame(best_features, columns = ['features', 'score'])
        dfs.append(df)
    return dfs

# Get top features for each cluster
dfs = get_top_features_cluster(X.toarray(), kmeans.labels_, 20)

Wrapping up

That’s the arc of it: collect a body of legal opinion, clean it into something workable, cluster to see the shape of the corpus, and then dig into sentiment at the aspect level. It’s a solid foundation for building out legal analytics — the pieces are modular, and each step points toward a richer reading of how courts actually write.

© 2023 Deepak Bastola

 

View source on GitHub